18  Matplotlib库基础

18.1 引言可视化的重要性

金融数据可视化:

  • 发现模式: 趋势、周期、异常
  • 沟通结果: 图表比数字更直观
  • 辅助决策: 支撑交易决策

18.2 本章学习目标

通过本章学习,你将能够:

  1. 说出 Figure(画布)与 Axes(坐标轴)的层次关系,区分 plt.* 快捷接口与 ax.* 面向对象接口,并根据场景选择
  2. 配置 plt.rcParams 实现中文与负号的正常显示,并排查”中文变方框”的三类常见原因
  3. 绘制折线图、柱状图、散点图、饼图四类基础图形,并设置图例、坐标轴标签、标题、网格与注释
  4. 读懂并复现平台按揭任务:解释 pmtipmtppmt 三者的关系与 numpy_financial 的现金流符号约定
  5. plt.savefig() 保存图形,并说明保存与显示的先后顺序

先修:章节 5章节 6章节 7(第5–7章 NumPy 数组基础,本章绘图所用的数据均为 NumPy 数组或 Python 列表)

18.3 画布与坐标轴:Matplotlib 的心智模型

在逐行拆解平台代码之前,先建立 Matplotlib 的层次模型,后面所有示例都会反复用到它:

  • Figure(画布): 最高层的容器,一张画布承载整幅图,负责整体尺寸、导出(savefig)等
  • Axes(坐标轴): 真正”画图”的对象,每个 Axes 拥有独立的 x 轴、y 轴、刻度、图例与标题;一张画布上可以摆放多个 Axes(子图)

与之对应有两套用法等价的接口:plt.* 快捷接口(pyplot 状态机,自动管理”当前画布/当前坐标轴”,单图快速出图最省事)与 ax.* 面向对象接口(先显式拿到 figax,再调用其方法,多子图与保存复用时对象明确、不会画错地方)。两套接口可以混用,但同一处尽量统一。

列表 18.1: 画布与坐标轴:两种接口画同一张图
# ==================== 导入必要的库 ====================
import matplotlib.pyplot as plt  # pyplot模块:提供plt.*快捷接口
import numpy as np  # NumPy用于生成数据

# ==================== 准备两行数据 ====================
months = np.arange(1, 13)  # 1至12月
fund_a = np.array([1.00, 1.02, 0.99, 1.05, 1.08, 1.06, 1.10, 1.12, 1.09, 1.15, 1.18, 1.20])  # 进取型基金净值
fund_b = np.round(np.linspace(1.00, 1.15, 12), 2)  # 稳健型基金净值:缓慢爬升

# ==================== 面向对象接口(推荐) ====================
# subplots()一次返回两个对象:画布fig与坐标轴ax
fig, ax = plt.subplots(figsize=(8, 4))  # 画布宽8英寸、高4英寸,默认包含1个坐标轴
ax.plot(months, fund_a, label='进取型基金')  # 在ax上画第一条线
ax.plot(months, fund_b, '--', label='稳健型基金')  # 在ax上画第二条线
ax.set_xlabel('月份')  # 面向对象接口的轴设置带set_前缀:ax.set_xlabel()
ax.set_ylabel('单位净值')  # ax.set_ylabel()
ax.set_title('面向对象接口绘制')  # ax.set_title()
ax.legend()  # 显示图例

# ==================== 观察层次结构 ====================
print('画布类型:', type(fig).__name__)  # Figure:最高层容器
print('坐标轴类型:', type(ax).__name__)  # Axes:真正画图的对象
print('画布上坐标轴个数:', len(fig.axes))  # 1
# plt.*接口背后操作的是"当前"坐标轴,可用plt.gca()查看,此处即ax
print('plt.gca()与ax是同一对象:', plt.gca() is ax)  # True

plt.tight_layout()  # 自动调整布局
plt.show()  # 显示图形

把上面代码中的 ax.plot 换成 plt.plotax.set_xlabel 换成 plt.xlabel,得到的图完全一样——plt.* 总是作用于”当前”坐标轴(plt.gca() 可查看它)。单图快速探索用 plt.* 足够;一旦涉及多子图(如本章”图表类型”示例的 2×2 布局)或画完还要保存、继续修改,显式的 fig/ax 写法对象清晰、不易出错,后续章节也以它为主。

18.4 中文显示与负号

Matplotlib 默认字体 DejaVu Sans 不含中文字形,直接画中文标题会得到一排方框;负号也有独立的坑:默认用 Unicode 减号(U+2212)渲染,而部分中文字体恰恰缺这个字形。两行 rcParams 配置一并解决:

列表 18.2: 中文显示与负号显示的配置
# ==================== 导入必要的库 ====================
import matplotlib.pyplot as plt  # Matplotlib绘图库

# ==================== 两项关键配置 ====================
# 第一行:默认字体DejaVu Sans不含中文字形,指定中文字体为黑体SimHei
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC', 'SimHei']  # 规范字体在前,SimHei 仅作后备  # Windows自带黑体;macOS/Linux可换本机已有中文字体
# 第二行:默认负号用Unicode减号U+2212渲染,中文字体常缺该字形
plt.rcParams['axes.unicode_minus'] = False  # 改用普通ASCII连字符'-'显示负号

# ==================== 绘制含中文与负值的图 ====================
months = range(1, 7)  # 1至6月
profit = [12, -5, 8, -3, 15, -8]  # 月度盈亏(万元),含负值
fig, ax = plt.subplots(figsize=(7, 3))  # 创建画布与坐标轴
ax.bar(months, profit, color='steelblue')  # 柱状图
ax.axhline(0, color='black', linewidth=0.8)  # 0参考线,区分盈亏两色区域
ax.set_title('月度盈亏(检验中文标题与负号显示)')  # 中文标题
ax.set_xlabel('月份')  # 中文x轴标签
ax.set_ylabel('盈亏(万元)')  # 中文y轴标签

# ==================== 查看配置是否生效 ====================
print('font.sans-serif =', plt.rcParams['font.sans-serif'])  # 打印当前字体设置
print('axes.unicode_minus =', plt.rcParams['axes.unicode_minus'])  # 打印当前负号设置

plt.tight_layout()  # 自动调整布局
plt.show()  # 显示图形

“中文变方框”的三类常见原因与降级办法:

  1. 本机没有所指定的字体: SimHei 是 Windows 字体,macOS/Linux 常未安装。此时 matplotlib 回退到 DejaVu Sans,控制台打印 findfont 警告,图上中文渲染为方框。降级办法:把字体名换成本机已有的中文字体(如 Microsoft YaHeiNoto Sans CJK SC),或暂时改用英文标签
  2. 字体名拼写错误: 同样触发 findfont 警告,检查引号内的名称是否与系统字体名完全一致
  3. 负号与中文是两件事: 负号方框由 axes.unicode_minus 单独控制,与中文字体设置互不影响

教学平台环境已配置中文字体,以平台渲染为准;本书本地校验环境(peter)未安装中文字体,运行本块时控制台输出正常、图上中文显示为方框并伴随 findfont 警告,属于预期现象而非代码错误。第20、21章的平台代码也都以同样的字体配置开头(设 font.sans-serif),本章平台任务则用等价的 plt.rcParams['font.family'] = 'SimHei'

18.5 基础绘图

业务背景:等额本息按揭

住房按揭中最常用的还款方式是等额本息:借款人在整个贷款期内每月支付一个固定总额(月供),30年期即360期,每期金额不变。但每期月供内部由两部分构成——当期利息与当期偿还本金,随着剩余本金逐月下降,利息占比越来越小、本金占比越来越大。numpy_financial 库恰好提供了对应的三个函数:pmt 给出每月的月供总额,ipmt 给出某期的利息部分,ppmt 给出某期的本金部分。

任务要求(归纳自块内任务注释与代码):设置中文字体与负号显示参数;导入 numpy_financial;按给定参数(本金 800 万元、年利率 5%、期限 30 年)计算第 360 期偿还的本金部分 ppmt 并用 print 输出(保留 2 位小数);生成 360 期的期数数组,逐期计算本金部分与利息部分;用面向对象接口(fig.add_subplot)在同一坐标轴上绘制三条曲线——每月偿还金额、每月偿还本金、每月偿还利息(均为取负后的正值),设置 x/y 轴标签与图例;将图形保存为 1.png。请将代码原样输入教学平台(注释除外),判定以平台为准。

平台任务(平台原始代码)

以下代码与教学平台任务要求完全一致:

列表 18.3: 平台原始代码
# ⚠️ 平台原始代码 - 请原样输入至教学平台(注释除外),平台才会判定答案正确
# 注:numpy_financial 包本地 peter 环境已安装(1.0.0),教学平台亦已内置
import matplotlib.pyplot as plt  # 导入Matplotlib绘图库
plt.rcParams['font.family'] = 'SimHei'  # 设置Matplotlib全局参数
plt.rcParams['axes.unicode_minus'] = False  # 设置Matplotlib全局参数
import numpy_financial as npf  # 导入NumPy数值计算库
import numpy as np  # 导入NumPy数值计算库
r=0.05 # 贷款的年利率
n=30 # 贷款的年数
principle=8e6  # 设置期数/数量为8000000
pay_month=npf.ppmt(rate=r/12,per=n*12,nper=n*12,pv=principle,fv=0,when='end') #计算第360期(末期)偿还的本金部分(ppmt)
print('每月的偿还的金额',round(pay_month,2))  # 输出每月的偿还的金额
T_list=np.arange(n*12)+1 #生成一个包含每次还款期限的数组
# 计算每期应还的本金部分
prin_month=npf.ppmt(rate=r/12,per=T_list,nper=n*12,pv=principle,fv=0,when='end')
# 计算每期应还的利息部分
inte_month=npf.ipmt(rate=r/12,per=T_list,nper=n*12,pv=principle,fv=0,when='end')
pay_month_list=pay_month*np.ones_like(prin_month)  # 生成与prin_month等长的每月总还款额数组
fig=plt.figure(figsize=(9,6))  # 创建图形画布
ax=fig.add_subplot(111)  # 添加单个子图占据整个画布
ax.plot(T_list,-pay_month_list,'r-',label=u'每月偿还金额')  # 在子图上绘制曲线
ax.plot(T_list,-prin_month,'m--',label=u'每月偿还本金金额')  # 在子图上绘制曲线
ax.plot(T_list,-inte_month,'b--',label=u'每月偿还利息金额')  # 在子图上绘制曲线
ax.set_xlabel(u'逐次偿还的期限(月)',fontsize=14)  # 设置X轴标签
ax.set_ylabel(u'金额',fontsize=14)  # 设置Y轴标签
ax.legend()  # 设置图例
plt.savefig("1.png")  # 保存图形至文件

预期输出(本机 peter 环境实际运行结果——numpy_financial 1.0.0 已可本地实跑并复现下述数值,在线成书本块仍设 eval: false 只回显代码,具体以平台运行结果为准):

  • 控制台输出一行:每月的偿还的金额 -42767.53。负号来自现金流方向约定(还款为付出现金);该值是 ppmt(360) 即第 360 期偿还的本金部分,量级约 4.28 万元,与真实月供约 4.29 万元(月供口径 pmt = -42945.73)非常接近,原因见下方深度解析
  • 生成文件 1.png:一张含三条曲线的折线图——一条红色水平直线(“每月偿还金额”,实际画在末期本金 42767.53 元的高度)、一条逐期上升的洋红色虚线(本金)、一条逐期下降的蓝色虚线(利息);任意一期本金与利息之和恒等于月供,两线在 360 期内呈”剪刀差”收敛
  • 图上中文(图例、轴标签)在平台环境正常显示;若运行环境未配置中文字体,中文会显示为方框(见上文”中文显示与负号”一节)

18.5.1 代码深度解析

pmt、ipmt、ppmt 三者的关系。对同一笔贷款,任意一期都满足恒等式 ppmt + ipmt = pmt:pmt 是等额本息下不变的月供总额,ipmt 是该期的利息部分,ppmt 是该期归还的本金部分。利息逐期递减、本金逐期递增,但两者之和始终等于月供。

per=n*12 选中哪一期per 参数指定要计算的期数编号。平台代码中 n=30,故 per=n*12per=360,选中的是第360期(最后一期)。此时剩余本金已非常小,当期利息极少,因此 ppmt(360) 几乎等于整个月供——用平台参数(本金800万元、年利率5%、30年)实算:第360期本金部分 ppmt(360) = -42767.53 元,末期利息 ipmt(360) = -178.20 元,而真正的等额本息月供 npf.pmt(...) = -42945.73 元(约4.29万元)。平台代码把它当作”每月偿还金额”画成水平线,图中该线实际位于末期本金水平(42767.53元)而非真实月供(42945.73元),两者数值接近只是末期”利息几乎还清”的结构性结果。

为什么数值是负的numpy_financial 沿用现金流方向约定:pv 为正表示”现在收到钱”(借入),则每期还款是”付出去的钱”,符号为负。因此 pmtppmtipmt 的返回值均为负,平台代码绘图时对序列取负号(如 -prin_month)把它们翻成正的金额再画到图上。

与第54章的口径差异。第54章的同一按揭任务(本金600万元、年利率4.9%、30年)用的是 pmt,即月供口径;本章平台任务用 ppmt(360),即末期本金口径。两章函数选择不同、口径不同,但各自与平台判定答案一致,均无对错之分。

列表 18.4: 等额本息月供与末期本金的对照验算
# 注:本块设为 eval: false 仅回显代码;numpy_financial 本地 peter 环境已可运行(下方数值经实跑核实)
# 输出数值已在上方正文给出,并经等额本息公式与360期摊还验算(末期余额归零)核实

# 与平台任务相同的参数:本金800万元,年利率5%,30年
r, n, principle = 0.05, 30, 8e6

monthly = npf.pmt(rate=r/12, nper=n*12, pv=principle, fv=0, when='end')       # 月供总额:-42945.73
ppmt_360 = npf.ppmt(rate=r/12, per=n*12, nper=n*12, pv=principle, fv=0, when='end')  # 第360期本金:-42767.53
ipmt_360 = npf.ipmt(rate=r/12, per=n*12, nper=n*12, pv=principle, fv=0, when='end')  # 第360期利息:-178.20

print('月供总额 pmt       =', round(monthly, 2))    # -42945.73
print('第360期本金 ppmt   =', round(ppmt_360, 2))   # -42767.53
print('第360期利息 ipmt   =', round(ipmt_360, 2))   # -178.20
print('ppmt + ipmt == pmt ?', abs(ppmt_360 + ipmt_360 - monthly) < 1e-6)  # True:任意一期本金+利息=月供
列表 18.5
# =============================================================================
# 题目:按揭贷款本息偿还可视化
# =============================================================================
# 本任务演示如何使用Matplotlib绘制金融时间序列图
# 场景:可视化30年期按揭贷款的本金和利息偿还过程

# ==================== 导入必要的库 ====================
import matplotlib.pyplot as plt  # Matplotlib的pyplot模块,提供类似MATLAB的绘图接口
import numpy as np  # NumPy数值计算库

# ==================== 按揭贷款参数 ====================
# principal:贷款本金(100万元)
principal = 1000000
# rate:年利率(4.9%,这是典型的商业贷款利率)
rate = 0.049
# years:贷款期限(30年)
years = 30

# ==================== 计算月供(等额本息) ====================
# monthly_rate:月利率 = 年利率 / 12
monthly_rate = rate / 12
# n_months:总月数 = 年数 * 12
n_months = years * 12
# 月供计算公式(等额本息)
# monthly_payment = principal * [r(1+r)^n] / [(1+r)^n - 1]
# 这是标准的按揭贷款月供公式
monthly_payment = principal * (monthly_rate * (1 + monthly_rate)**n_months) / ((1 + monthly_rate)**n_months - 1)

# ==================== 计算每年的本金和利息 ====================
# years_array:年份序列 [1, 2, 3, ..., 30]
years_array = np.arange(1, years + 1)
# balance:剩余本金,初始值为贷款总额
balance = principal
# interest_paid:每年支付的利息列表
interest_paid = []
# principal_paid:每年偿还的本金列表
principal_paid = []

# ==================== 逐年计算本金和利息 ====================
# for循环:遍历每一年
for year in years_array:
    # 初始化当年的利息和本金
    annual_interest = 0  # 当年累计利息
    annual_principal = 0  # 当年累计本金
    # 内层循环:遍历当年的12个月
    for _ in range(12):
        # interest:当月利息 = 剩余本金 * 月利率
        interest = balance * monthly_rate
        # principal_part:当月本金 = 月供 - 当月利息
        principal_part = monthly_payment - interest
        # 更新剩余本金:减去当月偿还的本金
        balance -= principal_part
        # 累加到年度总额
        annual_interest += interest
        annual_principal += principal_part
    # 将年度数据添加到列表
    interest_paid.append(annual_interest)
    principal_paid.append(annual_principal)

# ==================== 绘图 ====================
# plt.figure():创建画布
# figsize=(10, 6):画布宽度10英寸,高度6英寸
plt.figure(figsize=(10, 6))
# plt.plot():绘制折线图
plt.plot(years_array, interest_paid, label='利息', linewidth=2)
plt.plot(years_array, principal_paid, label='本金', linewidth=2)

# ==================== 设置图表元素 ====================
# plt.xlabel():设置X轴标签
plt.xlabel('年份', fontsize=12)
# plt.ylabel():设置Y轴标签
plt.ylabel('金额(元)', fontsize=12)
# plt.title():设置图表标题
plt.title('按揭贷款30年本金利息分解', fontsize=14)
# plt.legend():显示图例
plt.legend(fontsize=12)
# plt.grid():显示网格线
# alpha=0.3:网格线透明度30%
plt.grid(True, alpha=0.3)

# ==================== 添加注释 ====================
# plt.annotate():在图表上添加文本注释
# xy=(5, interest_paid[4]):箭头指向的坐标(第5年,第5年的利息)
# xytext=(10, interest_paid[4]):文本位置(第10年,第5年的利息高度)
# arrowprops=dict(arrowstyle='->'):箭头样式(单线箭头)
plt.annotate('前期利息占比高', xy=(5, interest_paid[4]), xytext=(10, interest_paid[4]),
             arrowprops=dict(arrowstyle='->'))

# ==================== 调整布局并显示 ====================
# plt.tight_layout():自动调整子图间距,避免元素重叠
plt.tight_layout()
# plt.show():显示图表
plt.show()

# ==================== 打印统计信息 ====================
# f-string:格式化字符串
# .2f:保留2位小数
print(f"月供: {monthly_payment:.2f}元")
# 月供 * 月数:总还款额
print(f"总还款: {monthly_payment * n_months:.2f}元")
# 总还款 - 本金:总利息
print(f"总利息: {monthly_payment * n_months - principal:.2f}元")

18.6 图表类型

列表 18.6
# =============================================================================
# 题目:常用图表类型示例
# =============================================================================
# 本任务演示Matplotlib中常用的四种图表类型
# 应用场景:投资组合数据的多样化展示

# ==================== 导入必要的库 ====================
import matplotlib.pyplot as plt  # Matplotlib绘图库
import numpy as np  # NumPy数值计算库

# ==================== 创建子图布局 ====================
# plt.subplots(2, 2):创建2行2列的子图布局(共4个图)
# figsize=(12, 10):画布宽度12英寸,高度10英寸
# fig:整个画布对象
# axes:子图数组(2x2的numpy数组)
fig, axes = plt.subplots(2, 2, figsize=(12, 10))

# ==================== 1. 折线图(Line Plot)====================
# np.linspace(0, 10, 100):在[0, 10]区间生成100个等间距点
x = np.linspace(0, 10, 100)
# y = sin(x):计算正弦值
y = np.sin(x)
# axes[0, 0]:第一个子图(左上角)
# .plot():绘制折线图
# linewidth=2:线宽2
axes[0, 0].plot(x, y, linewidth=2)
# .set_title():设置子图标题
axes[0, 0].set_title('折线图')
# .grid():显示网格
axes[0, 0].grid(True)

# ==================== 2. 柱状图(Bar Chart)====================
# categories:类别列表
categories = ['A', 'B', 'C', 'D']
# values:对应的数值
values = [20, 35, 30, 25]
# axes[0, 1]:第二个子图(右上角)
# .bar():绘制柱状图
# color='steelblue':柱子颜色(钢蓝色)
axes[0, 1].bar(categories, values, color='steelblue')
axes[0, 1].set_title('柱状图')

# ==================== 3. 散点图(Scatter Plot)====================
# np.random.seed(42):设置随机种子,确保散点图每次运行结果相同
np.random.seed(42)
# np.random.randn(100):生成100个标准正态分布随机数
x = np.random.randn(100)
y = np.random.randn(100)
# axes[1, 0]:第三个子图(左下角)
# .scatter():绘制散点图
# alpha=0.6:点的透明度60%(重叠部分会变深)
axes[1, 0].scatter(x, y, alpha=0.6)
axes[1, 0].set_title('散点图')
# .set_xlabel():设置X轴标签
axes[1, 0].set_xlabel('X')
# .set_ylabel():设置Y轴标签
axes[1, 0].set_ylabel('Y')

# ==================== 4. 饼图(Pie Chart)====================
# sizes:各部分的大小(占比)
sizes = [30, 20, 25, 25]
# labels:各部分的标签
labels = ['股票', '债券', '现金', '其他']
# axes[1, 1]:第四个子图(右下角)
# .pie():绘制饼图
# labels:各部分的标签
# autopct='%1.1f%%':自动显示百分比(保留1位小数)
axes[1, 1].pie(sizes, labels=labels, autopct='%1.1f%%')
axes[1, 1].set_title('投资组合分布')

# ==================== 调整布局并显示 ====================
# plt.tight_layout():自动调整子图间距
plt.tight_layout()
# plt.show():显示所有子图
plt.show()

18.7 本章小结

要点:

  • Matplotlib 是层次结构:Figure(画布)是容器,Axes(坐标轴)才是画图对象;plt.* 快捷接口操作”当前”坐标轴,ax.* 面向对象接口对象明确,多子图与保存复用时优先使用后者
  • 中文与负号靠两行 rcParams 配置:font.sans-serif 指定中文字体、axes.unicode_minus = False 改用 ASCII 减号;中文变方框的根源是当前字体不含中文字形
  • 折线图看趋势、柱状图比大小、散点图看关系、饼图看占比;图例、轴标签、标题、网格、注释是图表的五项基本要素
  • 等额本息的结构:月供总额不变,内部利息逐期递减、本金逐期递增,任意一期满足 ppmt + ipmt = pmt;numpy_financial 按现金流方向取符号,借入为正、还款为负,绘图时常取负翻正
  • plt.savefig() 要在 plt.show() 之前调用:在 Jupyter/教学平台环境中,show() 展示后当前图会被关闭,之后再保存得到的是空图

易错点:

  • 本地没有 SimHei 等中文字体时,图上中文显示为方框并伴随 findfont 警告——是环境问题而非代码错误,可换本机已有中文字体或以平台渲染为准
  • 多子图循环绘图时用 plt.* 接口容易把线画到”当前”坐标轴(而非想要的子图)上,应改用 axes[i, j].plot
  • 直接把 npf.pmt/npf.ppmt 的返回值画图会得到负值曲线,忘记取负是平台任务最常见的绘图偏差
  • plt.subplots(2, 2) 返回的是 2×2 的坐标轴数组,用 axes[0, 0] 索引;只有一个子图时返回的才是单个 Axes 对象
  • 饼图的 autopct='%1.1f%%' 中要写两个 %% 才能显示百分号,漏写会报格式化错误

18.8 动手与思考

以下练习每题附参考答案(默认折叠)。请先独立完成并写下你的判断,再点开对照,最后上机验证。

  1. 输出预测:不运行代码,先写出下面代码的输出结果,再上机检验你的判断。

    import matplotlib.pyplot as plt
    fig, axes = plt.subplots(2, 3)
    print(type(axes).__name__, axes.shape)
    print(len(fig.axes))
    print(plt.gca() is axes[1, 2])

    参考答案(先写下你的预测再点开)

    解题思路:逐行推演。plt.subplots(2, 3) 一次创建 2 行 3 列共 6 个坐标轴;多子图情形下第二个返回值 axes 是 NumPy 数组而非单个坐标轴对象,故第 1 个 print 输出类型名与形状 ndarray (2, 3)fig.axes 是画布上全部坐标轴对象的列表,故第 2 个 print 输出 6plt.gca() 返回“当前”坐标轴——subplots 按行逐个创建坐标轴,最后创建的是第 2 行第 3 列(右下角)那个,创建完成后它仍是“当前”坐标轴,因此第 3 个 print 输出 True。这段代码只创建画布、未画任何数据,6 个空坐标轴依然会随画布一起生成。

    # 验证脚本:逐行输出四个表达式的求值结果
    import matplotlib.pyplot as plt  # 导入pyplot快捷接口
    fig, axes = plt.subplots(2, 3)  # 创建2行3列共6个坐标轴,axes是2×3数组
    print(type(axes).__name__, axes.shape)  # axes的类型名与形状
    print(len(fig.axes))  # 画布上坐标轴对象的个数
    print(plt.gca() is axes[1, 2])  # “当前”坐标轴是否即最后创建的右下角子图

    预期输出(本机 peter 环境实际运行结果,具体以平台运行结果为准):

    ndarray (2, 3)
    6
    True

    回扣本章:对应本章小结“易错点”第 4 条——plt.subplots(2, 3) 返回的是 2×3 的坐标轴数组,须用 axes[i, j] 索引;以及“要点”第 1 条(plt.* 总是作用于“当前”坐标轴,plt.gca() 可查看它)。

  2. 概念辨析:plt.plot(x, y)ax.plot(x, y) 各自的作用对象是什么?为什么在 2×2 子图的循环绘图中推荐 ax.* 面向对象接口?figax 的职责又如何分工(提示:尺寸与 savefig 归谁管,轴标签与图例归谁管)?

    参考答案(点开前请先独立完成)

    解题思路:逐点作答。第一,plt.plot(x, y) 作用于“当前”坐标轴(即 plt.gca() 所指的对象),线画到哪根轴取决于此前哪个坐标轴最后被创建或点选;ax.plot(x, y) 作用于显式拿到的坐标轴对象 ax,落点在代码里一目了然,两套接口画出的图可以完全一样。第二,2×2 子图循环绘图时若沿用 plt.*,循环体内每一步都画在同一个“当前”坐标轴上,几条线会全部叠到一处而不是各归各的子图,因此推荐 ax.* 面向对象接口、按 axes[i, j].plot 明确落点;多子图与画完还要保存、继续修改的场景,显式的 fig/ax 对象清晰、不易画错地方。第三,figax 分工:fig(画布)负责整幅图层面的事——画布尺寸 figsize、子图数量与布局、整幅图导出 savefigax(坐标轴)负责图内要素——轴标签、标题、刻度与范围、图例、网格、注释。一句话:整幅图归 fig 管,图内细节归 ax 管。

    回扣本章:对应本章小结“要点”第 1 条与“易错点”第 2 条——多子图循环绘图应改用 axes[i, j].plot,避免 plt.* 把线画到“当前”坐标轴上。

  3. 变式任务:把本章”基础绘图”一节本地示例(lst-matplotlib-basic)的逐年本金、利息两条曲线拆到上下两个子图(2 行 1 列)分别绘制,各自设置子图标题与 y 轴标签,并让两个子图共享 x 轴(sharex=True),观察与单图叠画相比哪种更易读。

    参考答案(点开前请先独立完成)

    解题思路:数据口径完全沿用 列表 18.5(本金 100 万元、年利率 4.9%、期限 30 年的等额本息),改用 npf.ppmt/npf.ipmt 向量化算出 360 期各月本金与利息(取负翻正),再按 reshape(30, 12).sum(axis=1) 汇总成逐年序列;绘图改为 plt.subplots(2, 1, sharex=True),上图利息、下图本金。共享 x 轴后两个子图的年份刻度严格对齐,同一年份上下对照即可读出两组数值。与单图叠画相比:单图中两线在交叉点(约第 17 年)附近紧贴,谁高谁低难以分辨,且共用一个 y 轴刻度;拆成上下两个子图后各自趋势一目了然,交叉结构反而更容易指认——本例两条曲线量纲相同(均为金额),拆分的收益主要来自“同一位置只读一条线”;当系列更多或量级差异大时,分面共享 x 轴的收益会更明显。

    # 变式程序:把逐年本金、利息拆到共享x轴的上下两个子图
    import numpy_financial as npf  # 导入按揭现金流函数库
    import numpy as np  # 导入NumPy用于数组运算
    import matplotlib.pyplot as plt  # 导入Matplotlib绘图库
    periods = np.arange(1, 361)  # 360期期数序列
    monthly_principal = -npf.ppmt(rate=0.049/12, per=periods, nper=360, pv=1e6, fv=0, when='end')  # 各月偿还本金(取负翻正)
    monthly_interest = -npf.ipmt(rate=0.049/12, per=periods, nper=360, pv=1e6, fv=0, when='end')  # 各月偿还利息(取负翻正)
    annual_principal = monthly_principal.reshape(30, 12).sum(axis=1)  # 按年汇总本金(30年×12月)
    annual_interest = monthly_interest.reshape(30, 12).sum(axis=1)  # 按年汇总利息
    years_array = np.arange(1, 31)  # 年份序列1至30
    fig, axes = plt.subplots(2, 1, figsize=(8, 6), sharex=True)  # 上下两个子图共享x轴
    axes[0].plot(years_array, annual_interest, color='steelblue')  # 上图画逐年偿还利息
    axes[0].set_title('逐年偿还利息')  # 上图标题
    axes[0].set_ylabel('金额(元)')  # y轴标签
    axes[0].grid(True, alpha=0.3)  # 网格线
    axes[1].plot(years_array, annual_principal, color='coral')  # 下图画逐年偿还本金
    axes[1].set_title('逐年偿还本金')  # 下图标题
    axes[1].set_xlabel('年份')  # 共享x轴的标签只在最下方子图设置一次
    axes[1].set_ylabel('金额(元)')  # y轴标签
    axes[1].grid(True, alpha=0.3)  # 网格线
    plt.tight_layout()  # 自动调整布局
    plt.savefig('ch18_q3.png')  # 保存图形(也可用plt.show()显示)
    print('月供: {:.2f}元'.format(monthly_principal[0] + monthly_interest[0]))  # 月供=任意一期本金+利息
    print('第1年: 利息{:.0f}元, 本金{:.0f}元'.format(annual_interest[0], annual_principal[0]))  # 首年两口径对照
    print('第30年: 利息{:.0f}元, 本金{:.0f}元'.format(annual_interest[-1], annual_principal[-1]))  # 末年两口径对照

    预期输出(本机 peter 环境实际运行结果,图形在线版不执行、以教学平台渲染为准):

    月供: 5307.27元
    第1年: 利息48666元, 本金15022元
    第30年: 利息1659元, 本金62029元

    图形判读:上子图为一条单调下降的蓝色利息曲线(约 48666 元降至 1659 元),下子图为一条单调上升的橙色本金曲线(约 15022 元升至 62029 元);两条曲线的交叉点(单图叠画时约第 17 年处)在分面后由“上下两图同一年份的高度对比”呈现,年度本金首次超过年度利息的年份更容易指认。

    回扣本章:对应本章小结“要点”第 4 条(等额本息月供不变、内部利息逐期递减而本金逐期递增)与“易错点”第 3 条——npf.pmt/npf.ppmt 返回值为负,绘图前须取负翻正。

  4. 变式任务(平台任务同型改造):仿照平台任务的结构,把参数改为本金 600 万元、年利率 4.9%、期限 20 年,先在纸上推算月供的量级,再在平台上用 numpy_financial 验证你的估计,并说明第 240 期(末期)本金与月供的关系。

    参考答案(点开前请先独立完成)

    解题思路:先纸上推算量级。若不计利息,240 期纯本金摊还为 600 万 ÷ 240 = 2.5 万元/期,这是月供的下限;首月利息为 600 万 × (4.9%/12) ≈ 2.45 万元,若把还本全部压到末期的极端口径(每月只付利息、另存 2.5 万本金),月供约为 2.5 万 + 2.45 万 ≈ 4.95 万元,这是首月口径的上限。等额本息下剩余本金逐期下降,全程平均剩余本金略高于初始的一半(利息前置),故月供应介于两者之间且偏向 3.7 万—3.9 万元。实算:月供 pmt = -39266.64 元(约 3.93 万元,现金流约定下为负、取负翻正),与月供公式 \(P\cdot\frac{r(1+r)^n}{(1+r)^n-1}\)(r 为月利率、n=240)的验算结果完全一致。第 240 期本金 ppmt = -39106.96 元、末期利息 ipmt = -159.69 元,二者之和恰为月供:末期剩余本金已极小、当期利息近乎为零,因此末期本金几乎等于整个月供——这正是本章平台任务(800 万元、5%、30 年)中末期本金 42767.53 元与月供 42945.73 元非常接近的同一结构性结果。

    # 变式验算:600万元、年利率4.9%、20年等额本息的月供与末期本金、利息
    import numpy_financial as npf  # 导入按揭现金流函数库(教学平台已内置)
    loan_rate, loan_years, loan_principal = 0.049, 20, 6e6  # 年利率4.9%、期限20年、本金600万元
    monthly_pmt = npf.pmt(rate=loan_rate/12, nper=loan_years*12, pv=loan_principal, fv=0, when='end')  # 月供总额(现金流约定下为负)
    last_principal = npf.ppmt(rate=loan_rate/12, per=loan_years*12, nper=loan_years*12, pv=loan_principal, fv=0, when='end')  # 第240期本金部分
    last_interest = float(npf.ipmt(rate=loan_rate/12, per=loan_years*12, nper=loan_years*12, pv=loan_principal, fv=0, when='end'))  # 第240期利息部分(ipmt返回0维数组,用float取出数值)
    first_interest = float(npf.ipmt(rate=loan_rate/12, per=1, nper=loan_years*12, pv=loan_principal, fv=0, when='end'))  # 第1期利息(首月口径对照)
    monthly_rate = loan_rate / 12  # 月利率
    compound_factor = (1 + monthly_rate) ** (loan_years * 12)  # 复利因子(1+r)^n
    pmt_formula = loan_principal * monthly_rate * compound_factor / (compound_factor - 1)  # 等额本息月供公式验算
    print('月供总额 pmt = {:.2f} 元'.format(-monthly_pmt))  # 取负翻成正金额输出
    print('第240期本金 ppmt = {:.2f} 元'.format(-last_principal))  # 末期本金
    print('第240期利息 ipmt = {:.2f} 元'.format(-last_interest))  # 末期利息
    print('第1期利息 ipmt = {:.2f} 元'.format(-first_interest))  # 首期利息对照
    print('公式验算月供 = {:.2f} 元'.format(pmt_formula))  # 与pmt函数结果对照
    print('末期本金+末期利息 = {:.2f} 元'.format(-last_principal - last_interest))  # 验证恒等式ppmt+ipmt=pmt

    预期输出(本机 peter 环境实际运行结果,具体以平台运行结果为准):

    月供总额 pmt = 39266.64 元
    第240期本金 ppmt = 39106.96 元
    第240期利息 ipmt = 159.69 元
    第1期利息 ipmt = 24500.00 元
    公式验算月供 = 39266.64 元
    末期本金+末期利息 = 39266.64 元

    注意:以上为本题变式的独立验算代码;列表 18.3 对应平台任务的原始代码块仍须原样输入教学平台,不要用本变式替换。

    回扣本章:对应本章小结“要点”第 4 条(任意一期满足 ppmt + ipmt = pmtnumpy_financial 按现金流方向取符号,借入为正、还款为负)与“易错点”第 3 条(直接把返回值画图会得到负值曲线)。

  5. 思考题:为什么 axes.unicode_minus = False 能解决负号显示为方框的问题?如果不做任何字体配置,本章哪一类图表(提示:含负值的图)的刻度会首先暴露这个问题?

    参考答案(点开前请先独立完成)

    解题思路:问题根源在字符编码而非字体名拼写。Matplotlib 默认用 Unicode 减号 U+2212 渲染负刻度,而 U+2212 在 DejaVu Sans 与不少中文字体中缺失,字体链上找不到该字形时负号就被渲染为方框;axes.unicode_minus = False 把负号改为普通 ASCII 连字符 -,这个字形几乎任何字体都有,于是负刻度显示正常。因此该开关与 font.sans-serif 的中文字体设置是两件独立的事,中文正常而负号方框、或反过来,都可能出现。若不做任何字体配置,本章“含负值的图”——如“中文显示与负号”一节的月度盈亏柱状图(列表 18.2profit 含 -5、-3、-8)——的 y 轴负刻度会最先暴露方框问题;只画正值的图(如 GDP 柱状图)刻度里没有负号,不会触发。

    回扣本章:对应本章小结“要点”第 2 条(font.sans-serifaxes.unicode_minus = False 两行配置分别解决中文与负号两类问题),并呼应“中文显示与负号”一节三类常见原因的第 3 条——负号与中文是两件事。